AWS SDK (Node.js) で作成した タイムベース CloudWatch Events で Lambda Function を起動する
サーバーレスでAWSサービスを組み合わせてアプリケーション開発をしていると、連携するために必要な設定の不足で動かない…というシーンに出くわします。今回、以下のような構成で AWS Lambda をスケジュール起動する CloudWatch Events を作成したのですが、うまく動かず苦労したので記録を残します。こういうのを経験すると思うのですが、AWSコンソールで画面から操作するとき、裏でいろいろなリソースを作ってくれてるんですよね。
まずは大まかな流れと注意点から。AWS SDK を使って CloudWatch Events を作成する際は、以下のリソースを作成します。
- CloudWatch Events(タイムベース)のルールを作成する
- CloudWatch Events から起動するターゲットを指定する。今回のターゲットは Lambda Function
- Lambda Function に、CloudWatch Events からの起動を許可するようポリシーを作成する
次に注意点です。
- Lambda Function に、CloudWatch Events からの起動を許可するようポリシーを作成しないと、Lambda Function が起動しない
- このポリシーを作成する際、
SourceAccount
というパラメータを指定してしまうと Lambda Function が起動しない
これらはドキュメントCloudWatch イベント のトラブルシューティングおよび CloudFormation で CloudWatch Events を作成するブログにいずれも記載されていますが見事にハマったので記載しておきます。
動作環境
使用ツール | バージョン |
---|---|
AWS Lambda Node.js | Node.js 10.x |
aws-sdk-js | v2.467.0 |
TypeScript | 3.4.5 |
Lambda Function (Node.js) による CloudWatch Events の作成
CloudWatch Events の作成は Lambda Function から行いました。CloudWatch Events を作成して Lambda Function と接続するためには、以下の材料が必要になります。動作確認程度であれば決め打ちでも良いでしょう。
- CloudWatch Events のルール名
- 起動スケジュール。cron式で指定する
- CloudWatch Events から起動したい Lambda Function の ARN
- (必要に応じて)CloudWatch Events から起動したい Lambda Function への入力
ソースコードです。
import * as AWS from 'aws-sdk'; import { DeleteRuleRequest, PutRuleRequest, PutRuleResponse, PutTargetsRequest, RemoveTargetsRequest } from 'aws-sdk/clients/cloudwatchevents'; import { AddPermissionRequest, GetPolicyRequest, RemovePermissionRequest } from 'aws-sdk/clients/lambda'; import { IGreetingRequest } from '../greeting'; const Env = process.env.ENV!; const Region = process.env.REGION!; const GreetingLambdaArn = process.env.GREETING_LAMBDA_ARN!; const CloudWatchEvent = new AWS.CloudWatchEvents({ apiVersion: '2015-10-17', region: Region }); const AWSLambda = new AWS.Lambda({ apiVersion: '2015-03-31', region: Region }); exports.createEvent = (event: ICreateEventRequest): Promise<ICreateRuleResult> => { return CloudWatchEventHandler.createStartRule(event); }; interface ICreateEventRequest { ruleName: string; triggerHour: number; triggerMinutes: number; } interface ICreateRuleResult { ruleName: string; } class CloudWatchEventHandler { static async createStartRule(request: ICreateEventRequest): Promise<ICreateRuleResult> { // ① - スケジュールベースのルールを作成 const scheduleExpression = `${request.triggerMinutes} ${request.triggerHour} * * ? *`; const ruleName = `${Env}-${request.ruleName}`; const createRuleParam: PutRuleRequest = { Name: ruleName, State: 'ENABLED', ScheduleExpression: `cron(${scheduleExpression})` }; const putResult: PutRuleResponse = await CloudWatchEvent.putRule(createRuleParam).promise(); // ② - イベントのターゲットを作成。ここでは Lambda Function を起動する。JSONペイロードを渡す const lambdaInputJson: IGreetingRequest = { greet: 'こんにちは!CloudWatch Events から Lambda Functionを起動しました。' }; const putTargetParam: PutTargetsRequest = { Rule: ruleName, Targets: [ { Id: ruleName, Arn: GreetingLambdaArn, Input: JSON.stringify(lambdaInputJson) } ] }; await CloudWatchEvent.putTargets(putTargetParam).promise(); // ③ - Lambda Function が CloudWatch Eventsからの起動を許可するよう、パーミッションを追加 const addPermissionParams: AddPermissionRequest = { Action: 'lambda:InvokeFunction', FunctionName: GreetingLambdaArn, Principal: 'events.amazonaws.com', SourceArn: putResult.RuleArn, StatementId: createRuleParam.Name }; await AWSLambda.addPermission(addPermissionParams).promise(); return { ruleName } } }
① - スケジュールベースのルールを作成
CloudWatch Events のルールを作成します。今回はスケジュール実行としたいので 入力値から cron 式を構築します。スケジュールベースのイベントを構築する際は以下の2点に注意してください。
- cron で指定する時間は
UTC
です。日本時間で入力があった場合、時間や曜日を調整する必要があります PutRuleRequest
で指定するScheduleExpression
へは文字列を指定しますが、スケジュールベースで起動したい場合は明示的にcron()
という文字列を含めなければなりません。ちなみに定期実行の場合はrate()
という書き方になります。文字列で書くというのが、少し違和感がありますね
パラメータを指定して CloudWatchEvent.putRule()
でイベントを作成すると、 PutRuleResponse
が手に入ります。ここに含まれる RuleArn
は後で使うので保持しておきます。
② - イベントのターゲットを作成
CloudWatch Events から Lambda Function を作成するためターゲットを作成します。PutTargetsRequest
の形式を見るに、どうやらひとつのルールから複数のターゲットを起動できるみたいですね。このように、 TypeScript でコーディングを進めると AWS SDK のパラメータの型から必要な情報が類推できることも多く、開発効率の向上を実感できます。
今回つくるターゲットはひとつだけです。DynamoDB にあいさつを書き込む簡単な Lambda Function を用意しておき、それを起動します。ここで、起動する対象の Lambda Function ARN が必要になります。今回は環境変数で定義することとしました。また、あいさつ Lambda の入力値をJSON形式で指定します。CloudWatchEvent.putTargets()
を実行することでターゲットを作成できました。
③ - Lambda Function にパーミッションを追加
さて、これだけでは起動できません。正確には、CloudWatch Events 自体はその時が来れば動きますが、 Lambda Function を実行する権限がなく、見かけ上なにも起きないまま終わってしまいます。そこで、 Lambda Function 側に CloudWatch Events からの起動を許可するよう、ポリシーを追加します。 AddPermissionRequest
を構築しましょう。
- Action: Lambda Function を起動したい場合は
'lambda:InvokeFunction'
決め打ちでOKです。 - FunctionName: Function 名か ARN を指定します。ここでは環境変数から ARN が手に入っているのでそれを指定しています。
- Principal: CloudWatch Events からの起動を示す
'events.amazonaws.com'
決め打ちです。 - SourceArn: ①で保持した
putResult.RuleArn
を使います。 - StatementId: 他のルールと重複しなければ何でも良いです。ここでは単純にルール名をステートメントIDとしています。
!注意
AddPermissionRequerst
にはSourceAccount
というキーも指定できますが、これを指定してしまうとこれまた Lambda Function が起動できなくなるのでご注意ください。- 一連の作成処理を行うためには、CloudWatch Events を作成するポリシーと AWS Lambda にパーミッッションを付与するポリシーが必要です。
起動
CloudWatch Events 作成用の Lambda Function を、 AWS CLI から起動してみます。
aws lambda invoke \ --function-name stg-cloud-watch-event-sample-create-cloud-watch-event --log-type Tail \ --payload '{"ruleName":"wada-rule", "triggerHour":"12", "triggerMinutes":"30"}' \ outputfile.txt { "StatusCode": 200, "LogResult": "XXXXXXXXXXXXXXX", "ExecutedVersion": "$LATEST" }
すると、CloudWatch Events が作成され、ターゲットも起動しており…
Lambda Function の起動トリガーとして CloudWatch Events が追加されていることが確認できます。この状態になればOKです。
CloudWatch Events のトリガーにより Lambda Function が起動
CloudWatch Events のトリガーにより Lambda Function が起動するところまで見届けましょう。起動される Lambda はあいさつ文を受け取りそれを DynamoDB へ保存するシンプルな Function です。
import 'source-map-support/register'; import * as AWS from 'aws-sdk'; import { UpdateItemInput } from 'aws-sdk/clients/dynamodb'; import * as uuid from 'uuid'; import { IGreetingRequest } from '../greeting'; const EnvironmentVariableSample = process.env.GREETING_TABLE_NAME!; const Region = process.env.REGION!; const DYNAMO = new AWS.DynamoDB( { apiVersion: '2012-08-10', region: Region } ); exports.handler = async (event: IGreetingRequest) => { return HelloWorldController.hello(event); }; export class HelloWorldController { public static hello(payload: IGreetingRequest): Promise<IGreeting> { console.log(payload); return GreetingDynamodbTable.greetingStore(this.createMessage(payload)); } private static createMessage(payload: IGreetingRequest): IGreeting { return { title: 'hello, lambda!', description: payload.greet, } } } class GreetingDynamodbTable { public static async greetingStore(greeting:IGreeting): Promise<any> { const params: UpdateItemInput = { TableName: EnvironmentVariableSample, Key: {greetingId: {S: uuid.v4()}}, UpdateExpression: [ 'set title = :title', 'description = :description' ].join(', '), ExpressionAttributeValues: { ':title': {S: greeting.title}, ':description': {S: greeting.description} } }; return DYNAMO.updateItem(params).promise() } } export interface IGreeting { title: string; description: string; }
スケジュールでトリガーされ、あいさつ用 Lambda Function が起動すると、DynamoDB に書き込まれることが確認できました。
Lambda Function (Node.js) による CloudWatch Events の削除
作成したなら削除もしたいよねと考えるのが人のサガでしょう。考え方としては作成したリソースをすべて消せばOKです。リソースによっては順番が関係する(例えば、起動ターゲットが残っている場合にその CloudWatch Events は削除できない、など)ため、作成した順序の逆をたどると安全なことが多いです。今回の場合も以下の順で削除しています。
- あいさつ用 Lambda Function からパーミッションを削除
- CloudWatch Events からターゲットを削除
- CloudWatch Events のルールを削除
参考までにソースコードです。
import * as AWS from 'aws-sdk'; import { DeleteRuleRequest, PutRuleRequest, PutRuleResponse, PutTargetsRequest, RemoveTargetsRequest } from 'aws-sdk/clients/cloudwatchevents'; import { AddPermissionRequest, GetPolicyRequest, RemovePermissionRequest } from 'aws-sdk/clients/lambda'; import { IGreetingRequest } from '../greeting'; const Env = process.env.ENV!; const Region = process.env.REGION!; const GreetingLambdaArn = process.env.GREETING_LAMBDA_ARN!; const CloudWatchEvent = new AWS.CloudWatchEvents({ apiVersion: '2015-10-17', region: Region }); const AWSLambda = new AWS.Lambda({ apiVersion: '2015-03-31', region: Region }); exports.removeEvent = async (event: ICreateRuleResult): Promise<void> => { await CloudWatchEventHandler.removeRule(event); }; interface ICreateRuleResult { ruleName: string; } class CloudWatchEventHandler { static async removeRule(result: ICreateRuleResult): Promise<void> { // ① - Lambda Function のパーミッションを削除 const getStartPolicyParam: GetPolicyRequest = { FunctionName: GreetingLambdaArn }; const policy = await AWSLambda.getPolicy(getStartPolicyParam).promise(); if (policy.Policy) { const policyDocument: IPolicyDocument = JSON.parse(policy.Policy) as IPolicyDocument; // 削除しようとしている CloudWatch Events に相当するポリシードキュメントを探す const targetPolicy = policyDocument.Statement .find(statement => statement.Condition.ArnLike['AWS:SourceArn'].includes(result.ruleName)); // ポリシーが見つかったらSidを指定して削除する if (targetPolicy) { console.log('policy found:', targetPolicy); const removePolicyParam: RemovePermissionRequest = { FunctionName: GreetingLambdaArn, StatementId: targetPolicy.Sid }; await AWSLambda.removePermission(removePolicyParam).promise(); } } // ② - CloudWatch Events のターゲットを削除する const removeTargetParam: RemoveTargetsRequest = { Ids: [result.ruleName], Rule: result.ruleName }; try { await CloudWatchEvent.removeTargets(removeTargetParam).promise(); } catch (e) { console.log(`failed to delete targets:`, e); } // ③ - CloudWatch Events のルールを削除する const deleteRuleParam: DeleteRuleRequest = { Name: result.ruleName, Force: true }; await CloudWatchEvent.deleteRule(deleteRuleParam).promise(); } } interface IPolicyDocument { Statement: Statement[]; } interface Statement { Condition: Condition Sid: string; } interface Condition { ArnLike: ArnLike; } interface ArnLike { 'AWS:SourceArn': string; }
SAM テンプレート
これらをデプロイするための SAM テンプレートも載せておきます。まず、TypeScript のソースコードを Lambda Function 用にビルドするために webpack を利用しています。
const path = require('path'); const nodeExternals = require('webpack-node-externals'); module.exports = { mode: 'development', target: 'node', entry: { 'hello-world': path.resolve(__dirname, './src/lambda/handlers/hello/hello-world.ts'), 'cloud-watch-event-handler': path.resolve(__dirname, './src/lambda/handlers/cli/cloud-watch-event-handler.ts'), }, ... };
そしてビルドされた JavaScript ファイルを指定して CloudFormation でデプロイします。
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Resources: HelloWorldHelloLambda: Type: AWS::Serverless::Function Properties: FunctionName: !Sub ${Env}-${AppName}-hello Role: !GetAtt HelloWorldLambdaRole.Arn Handler: hello-world/index.handler Runtime: nodejs8.10 CodeUri: Bucket: !Ref DeployBucketName Key: !Sub ${ChangeSetHash}/dist.zip Timeout: 5 Environment: Variables: ENV: !Ref Env GREETING_TABLE_NAME: !Ref GreetingTableName REGION: !Ref AWS::Region HelloWorldCreateCloudWatchEventLambda: Type: AWS::Serverless::Function Properties: FunctionName: !Sub ${Env}-${AppName}-create-cloud-watch-event Role: !GetAtt HelloWorldLambdaRole.Arn Handler: cloud-watch-event-handler/index.createEvent Runtime: nodejs10.x CodeUri: Bucket: !Ref DeployBucketName Key: !Sub ${ChangeSetHash}/dist.zip Timeout: 5 Environment: Variables: ENV: !Ref Env REGION: !Ref AWS::Region GREETING_LAMBDA_ARN: !GetAtt HelloWorldHelloLambda.Arn # ① HelloWorldRemoveCloudWatchEventLambda: Type: AWS::Serverless::Function Properties: FunctionName: !Sub ${Env}-${AppName}-remove-cloud-watch-event Role: !GetAtt HelloWorldLambdaRole.Arn Handler: cloud-watch-event-handler/index.removeEvent Runtime: nodejs10.x CodeUri: Bucket: !Ref DeployBucketName Key: !Sub ${ChangeSetHash}/dist.zip Timeout: 5 Environment: Variables: ENV: !Ref Env REGION: !Ref AWS::Region GREETING_LAMBDA_ARN: !GetAtt HelloWorldHelloLambda.Arn # ② HelloWorldLambdaRole: Type: 'AWS::IAM::Role' Properties: RoleName: !Sub ${Env}-${AppName}-lambda-role ManagedPolicyArns: - 'arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess' - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' - 'arn:aws:iam::aws:policy/AmazonS3FullAccess' - 'arn:aws:iam::aws:policy/CloudWatchEventsFullAccess' # - 'arn:aws:iam::aws:policy/AWSLambdaFullAccess' # ③ Policies: - PolicyName: PermissionToPassAnyRole PolicyDocument: Version: '2012-10-17' Statement: Effect: Allow Action: - iam:PassRole Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/* AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Principal: Service: - 'lambda.amazonaws.com' Action: - 'sts:AssumeRole'
- ①②: あいさつ Lambda の ARN は同一テンプレート内であれば
!GetAtt
で取得できます。それを環境変数に設定しています。 - ③: CloudWatch Events を作成するためにマネージドポリシーを利用しています。CloudWatch Events と Lambda Function への強い権限をもたせました。
まとめ
Node.js の AWS SDK から、CloudWatch Events の作成と削除をやってみました。AWS SDK を使って開発していると、マネージドコンソールでの作業では見えないリソース作成手順や設定内容が見えたりして、面白いです。ときにはハマることもありますが、逆に AWS の内部構造が垣間見えることも多く、それは結果的にたくさんの応用が効く要素知識になりえます。
例えば今回は CloudWatch Events から Lambda Function を起動するためのポリシー設定を Lambda Function側に行う ことを見落としていたため時間を要しました。今後、別のサービスで「おかしい。Lambda Function が起動しない」というシーンに出くわしたときに、「もしかしたら Lambda 側にポリシー設定が必要なのかも?」と疑うことができます。このように要素知識を増やしていって、どんどん開発を効率的に進めていけたら良いなと思っています。